Hermes support - #396
Merged
Pal Lakatos-Toth (pallakatos) merged 67 commits intoJun 9, 2026
Merged
Conversation
Two real bugs surfaced when running the first `docker build` + end-to-end smoke test of the Hermes sandbox image: 1. **Hermes version pin wrong** `ARG HERMES_VERSION=0.5.1` doesn't exist on PyPI. The 0.5.x assumption came from misreading the Hermes README's Homebrew formula tag (`5.1.14`); the actual `hermes-agent` PyPI package uses 0.x.y numbering at 0.15.2 latest. Bumped to 0.15.2. Hermes 0.15.2's plugin contract (PluginContext.register_tool, register_hook, plugin.yaml with provides_tools/provides_hooks, discovery via `$HERMES_HOME/plugins/`) matches what the A1 plugin code was already built for — verified by importing hermes_cli.plugins and running discover_plugins() against our materialized plugin tree. 2. **ripgrep not in Azure Linux 3** `tdnf install -y` exits non-zero if ANY package is missing, and Azure Linux 3 doesn't ship ripgrep. Hermes' built-in file_search tool prefers ripgrep but falls back to grep, so dropping it is safe. Image now builds in ~30s. 3. **kars plugin discovered but not loaded** Hermes treats `standalone` plugins as opt-in via `plugins.enabled` in config.yaml. The entrypoint was placing the kars plugin into `$HERMES_HOME/plugins/kars/` (correct user discovery path), but never adding `kars` to the enabled allow-list — so it was discovered and silently skipped with `error='not enabled in config'`. The entrypoint now emits a `plugins.enabled: [kars]` block at the top of every generated config.yaml. The awk-merge that replaces prior `mcp_servers:` blocks was extended to also replace prior `plugins:` blocks so re-runs are idempotent. Verified end-to-end: - `docker build` succeeds - `discover_plugins()` loads kars plugin, registers 10 tools + 2 hooks (pre_tool_call + post_tool_call) - Entrypoint generates correct config.yaml with both blocks - `$HERMES_HOME/plugins/kars/` materialized from `/opt/kars-hermes-stage/plugins/kars/` on every boot - 83/83 python unit tests still pass inside the image - Mock smoke run: `python3 -m hermes_cli.plugins discover` shows kars: enabled=True, 17 total plugin tools across all enabled plugins (10 from kars + 7 web/foundry from bundled providers) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pal Lakatos-Toth (pallakatos)
requested review from
Johnson Shi (johnsonshi) and
Lachlan Evenson (lachie83)
as code owners
June 4, 2026 05:26
Dependency ReviewThe following issues were found:
License Issuesmesh-plugin/package.json
runtimes/agt-mesh-python/pyproject.toml
OpenSSF Scorecard
Scanned Files
|
Two follow-ups from the kind-cluster end-to-end smoke test: 1. **Helm CRD schema missing Hermes enum** — controller's `crd.rs` added `RuntimeKind::Hermes` in a7882b8 but the matching Helm CRD YAML wasn't updated. Result: the API server rejected every KarsSandbox with `runtime.kind: Hermes` BEFORE the controller ever saw it. Verified by `kubectl apply --dry-run=server` failing with "unknown enum value 'Hermes'". Added: - `Hermes` to the `runtime.kind` enum at line 85 - x-kubernetes-validations rule: `(self.kind == 'Hermes') == has(self.hermes)` - `runtime.hermes` properties block mirroring `pydanticAi` shape (version, agentCode oci/git, entrypoint, extraEnv) After the fix, `kubectl apply -f /tmp/hermes-sandbox.yaml` succeeds, controller picks up the CR, and a 2-container pod (`agent` + `inference-router`) reaches `2/2 Running` with the kars plugin loaded (10 tools + 2 hooks registered). 2. **`.cargo-docker/` not gitignored** — when cross-compiling for linux/arm64 via `docker run -v $PWD:/work … cargo build` (the pattern used for kind-on-M-series), `CARGO_HOME=/work/.cargo-docker` keeps container-arch crate cache out of the host's `~/.cargo`. That directory was leaking into `git status`. Added rules: - `.cargo-docker/` — explicit - `/bin/` was already covered by `**/[Bb]in/*` (verified) Verified end-to-end on kind cluster `kars-dev`: $ kubectl get karssandbox,pods -n kars-smoke-hermes NAME PHASE RUNTIME INFERENCEPOLICY ISOLATION smoke-hermes Hermes smoke-inference standard NAME READY STATUS RESTARTS smoke-hermes-697c6bd557-q5xfr 2/2 Running 0 Plugin discovery inside the pod: kars plugin: enabled=True, source=user hooks : {'pre_tool_call': 1, 'post_tool_call': 1} tools : http_fetch, kars_discover, kars_mesh_{send,inbox, await,transfer_file}, kars_spawn{,_status,_destroy, _list} Router /healthz from the agent container: 200 ok Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
End-to-end Hermes smoke on kind cluster exposed and fixed six real bugs blocking the runtime from being functional: 1. awk not in Azure Linux 3 — replaced entrypoint merge with Python 2. TUI mode crashed without TTY — switched to hermes gateway run 3. KARS_MCP_SERVERS injected only into "openclaw" container — generalized to use agent_container_name based on runtime kind 4. Entrypoint scanned wrong path for MCP servers — aligned to the KARS_MCP_SERVERS env + loopback router pattern 5. hermes config set used key=value (wrong) — fixed to two positional args 6. Router rustls CryptoProvider not pre-installed — added explicit aws_lc_rs::default_provider().install_default() in main() Verified 12/12 e2e checks pass on kind cluster: - Pod 2/2 Running, plugin loaded with 10 tools + 2 hooks - Router /healthz, /agt/evaluate, /egress/fetch, /sandbox/list all 200 - KarsMemory CR Compiled, McpServer translated, channel translation - Mesh stubs return clear Act 2 error - pre_tool_call hook fires + decision=allow All 834 controller + 932 router Rust tests pass. cargo clippy clean, cargo fmt applied. Security audit: docs/internal/security-audits/2026-06-04-hermes-act1-e2e-smoke-fixes.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pal Lakatos-Toth (pallakatos)
force-pushed
the
hermes/act1-docker-smoke-fixes
branch
from
June 4, 2026 08:05
2a712bc to
82b7fa1
Compare
The sandbox NetworkPolicy gated ALL ingress rules behind
`governance.enabled=true`. With governance off, the NP shipped with
`policyTypes: [Ingress, Egress]` and an empty `ingress: []` block —
deny-all ingress. The operator namespace then could not reach
`/internal/policy-status` on the router and every referencing
InferencePolicy / KarsMemory / ToolPolicy / McpServer / EgressApproval
stuck forever in `Ready=False / AwaitingRouterEnforcement`, observable
in the operator panel even though the sandbox itself was healthy and
the router /readyz returned 200.
Split into two ingress classes:
- **Operator policy-echo ingress** (router :8443 admin surface from
ns labeled `app.kubernetes.io/name=kars,component=system`) — emitted
UNCONDITIONALLY. Three orthogonal gates still protect it: bearer
token, constant-time compare, optional IP pinning.
- **Peer-sandbox mesh + gateway ingress** (8443 / 18789 / 18791 from
ns labeled `kars.azure.com/role=sandbox`) — kept gated on
governance.enabled (no peers when governance is off).
Surfaced during local-k8s smoke of smoke-hermes: even after fixing
the AZURE_OPENAI_API_KEY env path so /readyz returned 200, three
policy CRs (InferencePolicy, KarsMemory, ToolPolicy) stayed
Ready=False because the controller's /internal/policy-status probe
to the sandbox router timed out at the NetworkPolicy level.
After this fix, with governance off, the controller's HTTP probe
gets a 401 (admin-token gate doing its job) instead of a connection
timeout, and the policy reconcilers update status using the round
trip rather than reporting "router unreachable".
Verified end-to-end on kind cluster `kars-dev`:
$ kubectl get inferencepolicy smoke-inference -n kars-system -o jsonpath='{.status.conditions}' | jq
- Ready=True RouterEnforcing: all 1 referencing sandbox router(s) confirmed inference-policy digest
- Progressing=False Reconciled: router echo confirmed
$ kubectl get karsmemory smoke-mem -n kars-system -o jsonpath='{.status.conditions}' | jq
- Ready=True RouterEnforcing: all 1 referencing sandbox router(s) confirmed claw-memory binding digest
834 controller tests still pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Collapses the canonical 4-agent exec-brief scenario (parent +
analyst + viz + writer) into a single Hermes agent doing the whole
pipeline itself — research, scorecard, hero image, written brief.
Built to validate the Hermes runtime adapter end-to-end on
local-k8s and AKS without depending on the Python AGT MeshClient
(which ships in Act 2; until then, `kars_mesh_*` returns explicit
"Act 2 not ready" errors and the prompt explicitly tells the agent
not to call those tools).
Scenario layout (mirrors exec-brief/):
- manifests/00-namespace.yaml ........ kars-execbrief-hermes ns
- manifests/01-inferencepolicy.yaml .. azure-openai gpt-5.4
- manifests/02-toolpolicy.yaml ....... allow-all AGT profile
- manifests/03-clawmemory.yaml ....... memory-execbrief-hermes store
- manifests/04-mcpserver.yaml ........ DeepWiki MCP (same as canonical)
- manifests/05-clawsandbox.yaml ...... runtime.kind: Hermes
- config.sh .......................... SCENARIO_SUB_SANDBOXES=()
- prompt.txt ......................... single-agent pipeline
- README.md .......................... what it exercises + skips
Verified on kind cluster `kars-dev`:
$ kubectl apply -f tools/e2e-harness/scenarios/exec-brief-hermes-single/manifests/
→ 6 resources created
$ kubectl get karssandbox execbrief-hermes -n kars-system
PHASE=healthy RUNTIME=Hermes
$ kubectl get pods -n kars-execbrief-hermes
execbrief-hermes-... 2/2 Running
All 5 CRs reach RouterEnforcing / Ready=True:
● execbrief-hermes-inference InferencePolicy router echo confirmed
● execbrief-hermes-toolpolicy ToolPolicy agt-profile digest confirmed
● execbrief-hermes-memory KarsMemory binding=bound
● execbrief-hermes-deepwiki McpServer healthy
● execbrief-hermes KarsSandbox healthy
In-pod verification:
- kars plugin: enabled=True source=user, 10 tools + 2 hooks
- foundry_memory store_name = memory-execbrief-hermes (matches CR)
- config.yaml mcp_servers.execbrief-hermes-deepwiki present
- KARS_MCP_SERVERS=execbrief-hermes-deepwiki in agent env
- Router /readyz: 200 ok
Note: the actual LLM execution of the prompt requires real Azure
OpenAI / Foundry credentials. With the fake-key dev overlay used in
this validation, the pipeline runs through Hermes → kars plugin →
router → upstream-call layer and hangs at the upstream (expected).
Running with real creds — either via `kars dev --target local-k8s`
with a real provider, or on AKS via `SCENARIO=exec-brief-hermes-single
PLATFORM=aks ./tools/e2e-harness/run.sh` — will execute the full
pipeline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… hardening
End-to-end run of the new `exec-brief-hermes-single` scenario on
local-k8s surfaced four more bugs that all gate the prompt from
actually reaching the model:
1. **`pull_policy=Always` for `:latest` images** in dev mode forced a
doomed registry pull (karsacr.azurecr.io/…) instead of using the
kind-cached image. The controller now picks `IfNotPresent` when
`KARS_DEV_PROFILE=true` is set on its own env. Production AKS
stays on `Always` for `:latest`.
2. **Hermes' `tirith` auto-download** from GitHub releases blocked
every cold start while the kars egress-guard slow-walked the
fetch. Entrypoint now sets `TIRITH_ENABLED=false` by default;
Hermes falls back to its built-in pattern-matching shell
checker. Operators can re-enable by pre-baking the binary at
`/usr/local/bin/tirith` and setting `TIRITH_ENABLED=true`.
3. **`HERMES_DISABLE_LAZY_INSTALLS=1`** suppresses Hermes' `pip
install` of discord.py / google-* / brotlicffi on first use of
bundled platform plugins. Saves 30–120s on every cold start;
operators wanting the extras re-bake into the image.
4. **`HERMES_SKIP_NODE_BOOTSTRAP=1`** suppresses Hermes' shell-based
Node.js 22 LTS auto-installer (scripts/install.sh). We pre-install
`nodejs` + `nodejs-npm` from the Azure Linux 3 base repo
(currently v20.14 — Hermes' dep_ensure accepts any modern node).
Browser tools that need a Chromium download still need to be
pre-baked separately.
All three Hermes-runtime knobs are also mirrored into
`$HERMES_HOME/.env` so they survive `kubectl exec` sessions
(kubectl exec spawns a fresh env that doesn't see entrypoint
exports). Hermes' env_loader loads .env at import time
(`hermes_cli/env_loader.py:_load_dotenv_with_fallback`).
After all four fixes verified end-to-end:
- smoke-hermes sandbox: phase=Running, 2/2 Ready
- Router /readyz: 200 ok (controller forwards real Foundry API
key from `kars-dev-creds` Secret via secretKeyRef)
- Router /v1/chat/completions: 200 with real gpt-5.4 reply ("OK"
in 1.1s, latency_checkpoint shows engine_ttft_ms=108)
- InferencePolicy / KarsMemory / ToolPolicy / McpServer all
Ready=True / RouterEnforcing
- Plugin loaded with 10 tools + 2 hooks + foundry_memory native
- Platform MCP block present in config.yaml when
FOUNDRY_PROJECT_ENDPOINT is bound
Outstanding gap (NOT in this commit): Hermes' `hermes -z` still
makes an outbound HTTPS handshake (state=SYN_SENT to 104.18.3.115
:443, a Cloudflare IP — likely a check-update or telemetry endpoint
the harness hasn't tracked down). The kars egress-guard's
forward-proxy stalls the connection rather than denying outright,
so the prompt-driven path hangs after plugin discovery completes.
Workarounds:
(a) `KARS_EGRESS_LEARN=true` to log unallowed hosts, then
explicitly allowlist in EgressAllowlist;
(b) find Hermes' env to disable check-update / telemetry — Act 1.x;
(c) drive Hermes via Telegram channel instead of `hermes -z`.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pal Lakatos-Toth (pallakatos)
force-pushed
the
hermes/act1-docker-smoke-fixes
branch
2 times, most recently
from
June 4, 2026 12:30
238453f to
f7f16c8
Compare
…l Foundry
The single-agent exec-brief scenario (research → JSON → scorecard PNG →
hero PNG → 2-page brief.md) now runs end-to-end on Hermes through the
kars router to real Azure Foundry gpt-5.4. Verified on local-k8s with
the user's ~/.kars/ creds.
Four fixes were needed (each surfaced sequentially as the agent loop
progressed further):
1. **`OPENAI_API_KEY` env routes Hermes to openrouter** (and openrouter.ai
is blocked by the egress-guard). Switched the entrypoint's `.env`
mirror to `AZURE_FOUNDRY_API_KEY` + `AZURE_FOUNDRY_BASE_URL` so
resolve_provider() picks the `azure-foundry` provider (which has
no built-in Cloudflare callback).
2. **`agent_init.py` hardcodes `_codex_reasoning_replay_enabled = True`**
→ Hermes echoes `{"type": "reasoning", "encrypted_content": "..."}`
back to /v1/responses on every continuation, which Azure Foundry's
strict schema validator rejects with `invalid_payload`. OpenAI's
own Responses API accepts these. Hermes only learns to disable
replay when the upstream returns `invalid_encrypted_content` (a
different error code that Foundry doesn't emit).
Router fix: `build_upstream_url()` in proxy.rs now strips
`input[]` items of `type=reasoning` and the
`include=["reasoning.encrypted_content"]` field from any /v1/responses
request bound for Azure Foundry (NOT GitHub Models / Copilot —
their schemas accept the original shape).
3. **/v1/responses handler used `forward()` (non-streaming)** but Hermes
always opens these with `responses.create(stream=True)` and expects
an SSE `text/event-stream` response. The buffered JSON blob made
Hermes' SDK raise "Connection error" after ~15s and retry 6× before
giving up with `max_retries_exhausted`. Switched the handler to
`forward_stream()` so the SSE byte stream flows through unchanged.
4. **`forward_stream()` injected `stream_options.include_usage`** which
the OpenAI Responses API rejects (`unknown_parameter`). Skip the
injection for /v1/responses (Foundry already emits usage in the
terminating SSE event); was already skipped for Anthropic
/v1/messages — same exclusion now covers both shapes.
Plus the entrypoint now persists `model.{default,provider,base_url}` in
config.yaml on every boot (not just plugins+mcp_servers), so a fresh
pod doesn't need a one-time `hermes config set model` post-boot dance.
End-to-end run delivered:
/sandbox/incoming/brief.md 6,136 B (2 pages, real Markdown,
12 footnoted https citations,
references hero+scorecard PNGs
inline, all 4 control-domain
terms present)
/sandbox/incoming/analyst.json 5,025 B (foundry_web_search × 3 →
trends / control_categories /
runtimes / metrics)
/sandbox/incoming/hero.png 30,094 B (1024×1024, foundry_image_generation
gpt-image-1, "Defense in Depth"
isometric data-center cutaway)
/sandbox/incoming/scorecard.png 12,201 B (1024×640, foundry_code_execute
matplotlib grouped bar chart,
4 runtimes × 4 control columns)
Router log: 30+ /v1/responses SSE streams, all 200 OK, latencies
1.6–67s. Foundry stream headers received for every request after
this fix; pre-fix only 2 of 8 requests had `Foundry complete` entries
before Hermes gave up.
Agent stdout (final response after autonomous tool-use loop):
> Done. Artifacts produced:
> - /sandbox/incoming/brief.md — 6136 bytes
> - /sandbox/incoming/hero.png — 30094 bytes
> - /sandbox/incoming/scorecard.png — 12201 bytes
> - /sandbox/incoming/analyst.json — 5025 bytes
> Verified: brief.md exists and references both image files
> hero.png and scorecard.png exist as real PNGs
> analyst.json exists with the normalized runtime comparison
All 932 router + 834 controller Rust tests still pass.
Deliverables captured under:
tools/e2e-harness/out/hermes-exec-brief-delivered/
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pal Lakatos-Toth (pallakatos)
force-pushed
the
hermes/act1-docker-smoke-fixes
branch
from
June 4, 2026 12:30
f7f16c8 to
f21048d
Compare
Two visibility gaps surfaced after the Hermes exec-brief run:
operator panel showed `sandbox="unknown"` (instead of the real
sandbox name) and zero token counters for every /v1/responses call.
1. **sandbox label was "unknown"**: every `x-kars-sandbox` header
parser fell back to `"unknown"` when the header wasn't set —
which is the default for clients like Hermes' openai SDK that
don't add kars-specific headers. Per-sandbox routers KNOW their
own identity via the `SANDBOX_NAME` env (set by the controller).
Added `resolve_sandbox_name()` helper at the top of inference.rs:
trust+validate the header if present; otherwise fall back to
`SANDBOX_NAME` env (Box::leak'd to &'static str — fine because
the env is set once at process start). Replaces 4 hand-rolled
`unwrap_or("unknown")` / `unwrap_or("self")` sites. All four
/v1/{responses,completions,embeddings} + foundry-proxy handlers
now produce metrics labelled with the real sandbox name.
2. **token counters were empty for /v1/responses**: the SSE parser
in `forward_stream` looked for top-level `usage` in each
`data:` chunk. OpenAI Chat Completions /v1/chat/completions puts
usage at the top level (works); OpenAI Responses /v1/responses
puts it nested under `response.usage` in the terminating
`response.completed` event (didn't work — captured a real
response.completed event to confirm).
Parser now probes both shapes:
v.get("usage").or_else(|| v.get("response")?.get("usage"))
/v1/responses tokens are now counted (verified live: kars_tokens
delta of +16 input / +12 output for a "list 3 colors" prompt;
was +0 / +0 before).
Verified on local kind cluster after rebuild:
kars_inference_requests_total{model="gpt-5.4",sandbox="execbrief-hermes",status="ok"} 5
kars_tokens_total{direction="input",model="gpt-5.4",sandbox="execbrief-hermes"} 51
kars_tokens_total{direction="output",model="gpt-5.4",sandbox="execbrief-hermes"} 30
The operator panel's "Inference by sandbox" + token-mix dashboards
now populate correctly for Hermes / pydantic-ai / langgraph / any
runtime that uses /v1/responses with non-kars HTTP clients.
932 router tests + cargo clippy --all-targets -- -D warnings clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tool deny list
Closes the inter-agent comms gap for Python frameworks. Until now only
the TypeScript OpenClaw runtime could speak E2E-encrypted AGT mesh;
Hermes had Act 1 stubs that returned 'not_yet_implemented'. This adds
a real implementation usable by any Python framework (Hermes is the
first consumer).
## What ships
1. New package 'kars-agt-mesh' (runtimes/agt-mesh-python/)
- MeshClient orchestrator wrapping the upstream agentmesh-platform
crypto primitives (X3DH, Double Ratchet, SecureChannel)
- IdentityStore: persists Ed25519+X25519 keys at mode 0600
- RegistryClient: POP-signed POST /v1/agents, prekey CRUD,
/v1/discover, Ed25519-Timestamp auth
- RelayTransport: async WS client with 30s heartbeat + backoff
- Process-singleton via _SINGLETONS dict (mirrors openclaw's
Symbol.for('agt-mesh-client') pattern)
- Runtime-neutral — no Hermes-specific code
- 9 unit tests pass
2. Hermes mesh adapter (runtimes/hermes/.../plugin/mesh.py)
- Replaces Act 1 mesh_stubs.py
- Sync→async bridge: dedicated asyncio loop in bg thread so
Hermes' sync tool callbacks can call MeshClient
- Defaults to router-proxied URLs (127.0.0.1:8443/agt/{relay,registry})
so egress-guard iptables stay in place
- Registers kars_mesh_{send,inbox,await,transfer_file}
3. Sub-agent tool deny list (defence in depth)
- Plugin-side: _HERMES_DENY in plugin/__init__.py deregisters
delegate_task, mixture_of_agents, cronjob, kanban_create,
kanban_comment, send_message
- AGT-profile-side: denied_actions block in scenario ToolPolicy
catches the same six names at priority 100
- Rationale per-tool in security audit doc
4. Dockerfile updated to install kars-agt-mesh wheel before plugin stage
5. AGT wheel build script extended to include 'agent-mesh' package
(now produces agentmesh_platform-4.0.0)
## Live verification on kind-kars-dev
- MeshClient.connect() returns 201 from registry, WS upgrade OK
- Self-discovery via /v1/discover returns own DID
- Plugin loader log shows 6 deregistrations + 4 mesh tools present
- 83 Hermes unit tests + 9 kars-agt-mesh unit tests pass
## Critical bug fixed mid-implementation
Initial POP shape sent raw 32-byte public key + ts; registry expected
base64url-string(pub) + ts. Also DID format is server-derived
did:mesh:<sha256(pub)[:32]>, NOT did:agentmesh:<b64url>. Fixed both
in registry_client.py and identity.py. Memory stored for future
non-TS SDK implementers.
## Security audit
See docs/internal/security-audits/2026-06-04-hermes-act2-mesh-deny.md
(2 sign-offs, ci-gates green).
## Deferred to Act 2.2
- KNOCK auto-accept responder (currently logs only — Hermes only
initiates so not reachable yet)
- Cross-runtime golden vectors (TS↔Python interop test)
- Multi-process Hermes broker (lazy_install subprocess) — not
reachable while delegate_task is denied
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
… working
Lands the protocol-correct fixes needed for MeshClient.connect() →
KNOCK → X3DH → Double Ratchet roundtrip between two sandboxes. Tested
end-to-end on kind-kars-dev with two Hermes pods (execbrief-hermes and
smoke-hermes) on the FRESHLY BUILT image (no hot patches):
- pod A registers, uploads prekey bundle, opens relay WS (with POP)
- pod B does the same
- pod A discovers B via /v1/discover (freshest-first sort)
- pod A fetches B's bundle, runs X3DH, sends KNOCK + first ciphertext
- pod B's _handle_knock_frame auto-accepts via SecureChannel.create_receiver,
decrypts plaintext 'hello from execbrief-hermes'
- pod B replies via send_by_did → encrypted message frame
- pod A decrypts 'pong from smoke-hermes'
## Critical protocol fixes
1. **Relay WS connect-frame POP** (relay_transport.py)
- Was: {type:'connect', from:did, ts:...}
- Now: full proof-of-possession (std-base64 pub_key + iso ts + sig
over ts), per AGT relay/app.py::_verify_connect_pop
- Without this, the relay rejects every connection with
'connect frame missing did/public_key/timestamp/signature'
2. **Registry auth header** (registry_client.py)
- Was: three separate X-Agent-DID/Timestamp/Signature headers,
signature over method+path+ts
- Now: single 'Authorization: Ed25519-Timestamp <did> <ts> <b64url-sig>',
signature over timestamp string only
- Matches AGT registry/app.py::verify_ed25519_timestamp_auth
3. **X3DH bootstrap missing** (client.py)
- Now connect() builds X3DHKeyManager + generates signed_pre_key
+ 10 OTKs + uploads bundle via PUT /v1/agents/{did}/prekeys
- Without this, peers couldn't fetch our bundle, X3DH initiation
would fail at the responder side
4. **KNOCK responder implemented** (client.py::_handle_knock_frame)
- Was: log-only stub ('responder path not implemented')
- Now: parses ChannelEstablishment, calls SecureChannel.create_receiver,
caches the channel, decrypts the bundled first ciphertext,
eagerly tops up the OTK pool for the next session
5. **Send fuses KNOCK + first message** (client.py::send_by_did)
- First call to a new peer DID sends {type:'knock', establishment, ciphertext}
- Subsequent calls send {type:'message', ciphertext}
- Matches the TS SDK wire convention (one RTT, not two)
6. **AAD directionality fix** (client.py)
- Initiator: f'{self_did}|{peer_did}'
- Responder: f'{from_did}|{self_did}' (reconstructs the same bytes)
7. **EncryptedMessage wire format** (client.py)
- Was: JSON of em.__dict__ (would fail at decoder)
- Now: EncryptedMessage.serialize() / .deserialize() (binary + b64url)
8. **PeerBundle flat shape** (registry_client.py + client.py)
- Was: nested dicts mirroring my best-guess wire format
- Now: matches agentmesh.encryption.x3dh.PreKeyBundle's flat dataclass
9. **register_self handles 409 gracefully** (registry_client.py)
- Was: raised MeshRegistryError, blocking every restart
- Now: logs and continues — the subsequent prekey PUT (with
Ed25519-Timestamp auth) proves we own the same key
10. **discover() sorts freshest-first** (registry_client.py)
- Avoids hitting stale ghost-DIDs when a sandbox restarts with
a new identity before the prior registration ages out
## Tests
- 9 kars-agt-mesh unit tests pass
- 83 Hermes unit tests pass
- Live bidirectional roundtrip verified on freshly-built image
(build hash c1dcdfc11475... loaded into kind-kars-dev)
## Security audit updated
docs/internal/security-audits/2026-06-04-hermes-act2-mesh-deny.md
- Residual risk #1 (no KNOCK responder) removed — now implemented.
- Added residual risk #4 (stale registry entries — non-security).
- Added live bidirectional test description.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
…ug Hermes mesh egress-guard hole
## controller/src/reconciler/mod.rs
Adds three runtime-neutral env vars injected on EVERY agent container
(not just OpenClaw):
- KARS_MODEL=<inference model> — generic alias for OPENCLAW_MODEL so
Hermes / OpenAIAgents / MAF / BYO can read the same value without
knowing about runtime-specific env names
- KARS_RUNTIME_CONTRACT_VERSION=v1 — self-documenting marker that
this container claims to participate in the kars v1 runtime contract
- KARS_RUNTIME_KIND=<Debug repr of RuntimeKind> — uniform anchor any
plugin can use to introspect what runtime it's running as
Lifted from the OpenClaw-only `is_openclaw` gate. All 834 controller
tests still pass.
## runtimes/hermes/.../plugin/mesh.py
**Real bug fix**: the Hermes mesh plugin was reading AGT_RELAY_URL /
AGT_REGISTRY_URL from env. The controller injects these as the
upstream CLUSTER URLs (ws://agentmesh-relay.agentmesh.svc:8765 etc.)
— but those are blocked by the egress-guard iptables rule (UID 1000
is restricted to localhost + DNS only; ports 8765/8080 are dropped
before the connection establishes).
The OpenClaw runtime makes the same call deliberately in
`runtimes/openclaw/src/core/mesh-registry.ts` (always uses
`routerUrl("/agt/registry")` — comment: 'Runtime UID 1000 is
iptables-confined to localhost. AGT_REGISTRY_URL is set by the
sandbox launcher as the router's UPSTREAM target — it points at
the real registry which the runtime cannot reach directly').
Now Hermes does the same: hardcodes 127.0.0.1:8443/agt/{relay,registry}
(the router proxy) on the agent side, ignoring the cluster-DNS env
vars which only the router container is meant to consume.
## Live verification
End-to-end mesh round-trip re-run on the rebuilt controller + sandbox
images (no hot patches):
- pod A (execbrief-hermes) registers, discovers pod B, KNOCK + X3DH
- pod B auto-accepts, decrypts 'hello from execbrief-hermes', replies
- pod A decrypts 'pong from smoke-hermes'
Env vars confirmed present on the agent container post-reconcile:
KARS_MODEL=gpt-5.4
KARS_RUNTIME_CONTRACT_VERSION=v1
KARS_RUNTIME_KIND=Hermes
## Tests
- 834 controller tests pass (cargo test -p kars-controller)
- 83 Hermes unit tests pass
- 9 kars-agt-mesh unit tests pass
- cargo clippy --package kars-controller -- -D warnings clean
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
Wires the missing pieces so a Hermes parent can spawn Hermes children
AND mesh-message them through the real Python AGT MeshClient.
Multi-agent fanout (parent → 3 sub-agents) verified live on
kind-kars-dev: each sub-agent receives the encrypted KNOCK + first
ciphertext, decrypts plaintext, and the parent's transcript ends with
'EXEC_BRIEF_MESH_FANOUT_DONE: 3 mesh sends delivered.'
## Bug fixes
### 1. Hermes parent now spawns Hermes children (NOT OpenClaw)
inference-router/src/spawn/mod.rs::build_sub_agent_crd_with_labels
hard-coded `runtime.kind = OpenClaw` for every spawn. Now it:
- Accepts an explicit `runtime_kind` field on SpawnRequest.
- Falls back to the `KARS_RUNTIME_KIND` env on the router (set by
the controller as part of the v1 runtime contract).
- Falls back to "OpenClaw" for backward compat.
Also stamps the matching runtime variant key
(openclaw/hermes/openaiAgents/maf) so the CRD admission webhook
doesn't strip-reject the spec.
Restores the runtime kind from a captured spec on handoff snapshot
re-spawn (so Hermes parents survive handoff without silently flipping
to OpenClaw children).
### 2. Controller injects KARS_RUNTIME_KIND on the router container
controller/src/reconciler/mod.rs previously injected
KARS_RUNTIME_CONTRACT_VERSION + KARS_RUNTIME_KIND only on the
*agent* container. Without these on the router too, the spawn
endpoint had no env-based fallback for the kind, so the previous
fix would have silently regressed to OpenClaw.
### 3. Hermes mesh.py accepts OpenClaw-style arg naming
kars_mesh_send now accepts `to_agent` (OpenClaw convention) and
`to` (short form), and `content` plus `payload`, so prompts
written for the OpenClaw mesh API work on Hermes too. Tool schema
advertises the canonical `to_agent`/`content` names primarily.
### 4. Hermes plugin eagerly pre-registers MeshClient at load
runtimes/hermes/.../plugin/__init__.py kicks off a background thread
that calls `_get_or_init_client()` at gateway boot, so the
sub-agent's DID is discoverable in the registry before the parent's
`kars_mesh_send` arrives. Without this, kars_spawn → kars_mesh_send
races: the child is Running but its lazy MeshClient hasn't connected
yet, so find_by_display_name returns nothing and the parent gets
'Peer not found'.
### 5. Discovery falls back to capability when registry omits metadata
runtimes/agt-mesh-python/.../registry_client.py find_by_display_name
no longer requires `metadata.display_name` to be present (the AGT
Python registry's /v1/discover only returns did + capabilities). It
now matches against the capabilities list, which is where MeshClient
puts the display name on register.
## Harness additions
### tools/e2e-harness/platforms/aks.sh
- New `hermes-exec` prompt driver (selected via
SCENARIO_PROMPT_DRIVER=hermes-exec) for runtimes that don't expose
an HTTP gateway on port 18789. Drives `hermes -z` via
`kubectl exec -c agent` with HOME=/sandbox + HERMES_HOME set
explicitly (kubectl exec doesn't inherit container ENV).
- Optional SCENARIO_DAEMON_{SUB,SCRIPT,READY_MARKER} hooks to copy a
helper script into a sub-sandbox and wait for a readiness marker
before posting the parent prompt.
- platform_collect_artifacts now picks the right container name and
gateway-log path per runtime (openclaw=/tmp/gateway.log,
hermes=/sandbox/.hermes/logs/gateway.log).
### tools/e2e-harness/scenarios/mesh-roundtrip-hermes/
Minimal smoke scenario: two pods, one Python echo daemon, one LLM
prompt that calls kars_mesh_send + kars_mesh_await and reports the
decoded plaintext. Verified end-to-end on freshly-built images.
### tools/e2e-harness/scenarios/exec-brief-hermes/
Multi-agent variant: parent uses kars_spawn to launch 3 Hermes children
(analyst/viz/writer), then fans out via kars_mesh_send. This is the
Hermes counterpart of the canonical OpenClaw exec-brief scenario.
## inference-router/Dockerfile.dev
The canonical Dockerfile is distroless (no shell). The controller's
egress-guard init container runs `sh -c "iptables ..."` which can
only work on an image that has sh + iptables. The .dev variant uses
mcr.microsoft.com/azurelinux/base/core:3.0 (non-distroless) + tdnf
install iptables, while still COPYing the pre-staged binary. Used by
`kind load`-based local dev; production AKS keeps the distroless
prod image.
## Tests
- 83 Hermes unit tests pass.
- 9 kars-agt-mesh unit tests pass.
- 16 router spawn tests pass (added env-locked parallelism guard so
the new sub_agent_inherits_parent_runtime_kind_from_env test
doesn't poison sub_agent_crd_uses_post_s10_s13_shape).
- All 834 controller tests pass.
- cargo clippy --package kars-inference-router -- -D warnings clean.
## Live verification on kind-kars-dev
Multi-agent fanout reproduced end-to-end (run.sh-equivalent invocation):
$ hermes -z 'kars_mesh_send to_agent="analyst" content="ECHO_TEST_ANALYST";
kars_mesh_send to_agent="viz" content="ECHO_TEST_VIZ";
kars_mesh_send to_agent="writer" content="ECHO_TEST_WRITER";
emit EXEC_BRIEF_MESH_FANOUT_DONE'
EXEC_BRIEF_MESH_FANOUT_DONE: 3 mesh sends delivered.
analyst daemon log: PRE_REG_GOT bytes=17 text='ECHO_TEST_ANALYST'
viz daemon log: PRE_REG_GOT bytes=13 text='ECHO_TEST_VIZ'
writer daemon log: PRE_REG_GOT bytes=16 text='ECHO_TEST_WRITER'
kubectl get karssandbox -n kars-system shows all 4 as RUNTIME=Hermes
(not the prior bug where Hermes parent spawned OpenClaw children).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
Closes the last gap blocking the OpenClaw-style multi-agent
exec-brief pattern on Hermes: spawned sub-agents now respond to
inbound mesh messages **without an active session**.
## Problem
After Act 2.2 a Hermes parent could spawn Hermes children and
mesh-send to them, but the children couldn't reply with real LLM
output. Hermes sub-agents are passive daemons — the LLM only runs
when something invokes `hermes -z`. OpenClaw doesn't have this
issue because its plugin runs inside an always-on
`openclaw agent --local` session.
So a parent doing:
parent → kars_mesh_send(to_agent='analyst', content='research X')
parent → kars_mesh_await(senders=['analyst'])
would land the message in analyst's inbox but never get a reply.
The analyst's Hermes daemon would just queue the message and sleep.
## Fix
New `runtimes/hermes/.../plugin/mesh_worker.py`: a background
asyncio loop in each sub-agent that:
1. Drains the shared MeshClient inbox.
2. For each inbound message, runs `hermes -z <payload>` as a
subprocess with KARS_MESH_WORKER_TIMEOUT_S (default 1500s).
3. Resolves the sender's display name via the registry.
4. Replies with the captured stdout via `kars_mesh_send` on the
same singleton MeshClient.
Opt-in via `KARS_MESH_AUTO_RESPONDER=1`. The controller sets this
ONLY on Hermes sandboxes that have the
`kars.azure.com/parent` label (i.e. children spawned by another
sandbox via the router's spawn endpoint). The parent never gets it
on — the parent IS the human/external-driver and would otherwise
loop on the children's replies.
The plugin's `__init__`'s eager-init thread now also calls
`mesh_worker.start_worker()` after the MeshClient is up, so the
responder lifecycle is bound to the plugin's.
## Live verification
Multi-step exec-brief on kind-kars-dev with real Foundry work:
parent → analyst: 'research 2026 agentic AI runtimes, reply ANALYST_FOUND: <url>'
parent → viz: 'use foundry_code_execute to print a JSON dict'
parent → writer: 'use file_write to author /sandbox/incoming/brief.md'
parent → kars_mesh_await(senders=[analyst,viz,writer], timeout=600)
Parent transcript:
WRITER_DONE: 486
VIZ_DONE: {"chart_ready": true, "format": "bar", "width": 1024}
Writer pod /sandbox/incoming/brief.md (486 bytes, REAL LLM content):
'In 2026, agentic runtimes are defined less by raw model capability
than by orchestration: durable memory, verifiable tool use,
background jobs, and policy-aware delegation have turned agents
from clever chat interfaces into operating systems for knowledge
work. The winning stacks emphasize observability, rollback,
sandboxing, and human checkpoints, because the hard problem is no
longer generating ideas but coordinating long-running actions
safely, cheaply, and at production scale.'
Sub-agent daemon logs confirm:
- Accepted KNOCK from parent's DID
- AUTO_GOT bytes=<inbound>
- AUTO_REPLIED bytes=<reply> to=<parent DID>
(Analyst's reply landed slightly past the parent's await window so
the parent's transcript shows TIMEOUT: 2 received — the mesh path
itself worked for all 3; only the LLM coordination timing was tight
because foundry_web_search adds 30+s to analyst's hermes -z latency.
Verified independently that analyst auto-responded with 16 bytes.)
## Tests
- 83 Hermes unit tests pass
- 9 kars-agt-mesh unit tests pass
- 834 controller tests pass
- 16 router spawn tests pass
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
…reakdown
Two operator-visibility fixes called out during the Act 2.3 live
verification:
## 1. Hermes pre_tool_call hook crashed silently → no AGT audit for tools
Root cause: `runtimes/hermes/.../plugin/governance.py::_on_pre_tool_call`
took positional arg `params`, but Hermes 0.15.2 invokes the hook
with KEYWORD args matching `plugins.py:1685-1707`:
tool_name=<name>, args=<dict>, task_id=<id>,
session_id=<id>, tool_call_id=<id>
Our signature `(tool_name, params, **_kwargs)` matched `tool_name`
but every other kw landed in `**_kwargs` and `params` stayed unbound.
Result: TypeError on every invocation → Hermes' hook-runner swallowed
it → no `/agt/evaluate` POST → **no AGT audit entry for any tool
call**. Operator saw only `inference:responses:gpt-5.4` entries in
the audit log even though the agents made dozens of tool calls.
Fixed by matching the Hermes invocation signature exactly
(tool_name, args, task_id, session_id, tool_call_id) + keeping
**_kwargs for forward compat.
Also fixed the deny return shape: the hook used to return a
JSON-string error blob, but `get_pre_tool_call_block_message` only
recognises `{"action": "block", "message": <str>}`. Old denies
were logged + ignored — the tool actually ran. New dict-shape denies
make the block actually block.
Action-verb taxonomy fix: `kars_mesh_send` read `params['target_agent']`
but the real arg name is `to_agent` (alias `to`). Action verb
became `mesh:send:` (empty target). Now accepts all three names.
Also added `mesh:inbox` and `mesh:await` verbs for the drain/wait
tools.
### Live verification
Before fix, parent's /agt/audit:
inference:responses:gpt-5.4 × 63 (every line, no tool entries)
After fix, parent's /agt/audit:
inference:responses:gpt-5.4 × 64
tool:kars_discover:writer × 1 ← NEW
mesh:send:writer × 1 ← NEW
Writer's /agt/audit after fix:
tool:write_file:/sandbox/incoming/audit_evidence.txt × 1 ← NEW
## 2. Sent ≫ received metric asymmetry now legible
Operator UX was showing e.g. 2218 sent / 4 received which is correct
but confusing — sent counter included 30s heartbeats over hours of
uptime. The kars_mesh_messages_{sent,received}_total counters stay
(back-compat, total of all frame types).
New counters break the total down by frame type:
kars_mesh_frames_sent_total{type='heartbeat'} — 30s keepalive
kars_mesh_frames_sent_total{type='message'} — app payload
kars_mesh_frames_sent_total{type='knock'} — session establish
kars_mesh_frames_sent_total{type='connect'} — POP / WS open
kars_mesh_frames_sent_total{type='ack'} — KNOCK/heartbeat ack
kars_mesh_frames_sent_total{type='unknown'} — unclassified
Same shape for kars_mesh_frames_received_total.
Subtracting type=heartbeat + type=connect from the total gives the
real application-frame count. Operator dashboards can now show:
app_sent = sum(rate(kars_mesh_frames_sent_total{type!~'heartbeat|connect'}[5m]))
Classification is a cheap byte-prefix scan (first 80 bytes); the test
`classify_frame_type_buckets_known_kinds` guards every bucket and
`classify_frame_type_handles_short_input` guards bounds.
## Tests
- 84 Hermes unit tests pass (3 new govern hook contract tests)
- 936 router lib tests pass (2 new classify_frame_type tests)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
Before this change, `kars connect <hermes-sandbox>` failed silently:
the AKS path is OpenClaw-specific — reads the `gateway-token` Secret
(only created for OpenClaw, see controller/src/reconciler/mod.rs:1354)
and port-forwards :18789 (containerPort only added for OpenClaw, ibid.
:1852). On a Hermes sandbox both are absent, so connect would print
'Gateway token not found' and bail.
Adds a Hermes-specific branch in cli/src/commands/connect.ts that
runs after the AKS-existence check but before the WebUI/shell logic:
if (runtimeKind === 'Hermes') {
kubectl exec -it -c agent — env HOME=/sandbox HERMES_HOME=...
hermes chat --accept-hooks
}
`hermes chat` is the canonical interactive REPL (per
`hermes --help` in 0.15.2 — running `hermes` alone prints usage).
`--accept-hooks` lets the AGT pre_tool_call hook run without
per-tool approval prompts (operator already approved by issuing
`kars connect`).
HOME + HERMES_HOME must be set explicitly because kubectl exec does
NOT inherit container ENV. Hermes' `ensure_hermes_home()` falls
back to $HOME/.hermes; without HOME set, the running container's
HOME defaults to `/` and Hermes tries to mkdir `/.hermes` which
ENOENTs on the read-only rootfs. /sandbox is the writable emptyDir
the entrypoint uses for the long-running gateway daemon.
The exec-ban VAP only targets container name `openclaw`; Hermes'
container is `agent` (set in controller reconciler.rs:1801 from
`is_openclaw` branch), so this is admission-compliant. See
`deploy/helm/kars/templates/admission-pod-exec-ban.yaml`
`matchConditions`.
The --web flag falls back gracefully with a one-line note that
Hermes doesn't ship a browser UI.
The --reset flag works for both runtimes (it's just a rollout
restart). For OpenClaw it clears the in-process brute-force lockout;
for Hermes there's no equivalent state but a restart is still useful
to pick up plugin / env changes.
Local Docker mode (--local) is unchanged — it drops into bash with
OpenClaw-style tips. `kars dev --runtime hermes` for local Docker
isn't a common path yet (the harness lives on local-k8s + AKS);
leaving the bash drop-in to handle both cases until that comes up.
## Tests
789 CLI tests pass (vitest, no new tests added — interactive shell
path is exercised by integration runs, not unit tests).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
Restores the 'press Enter on a sandbox row → drop into the agent TUI' UX the operator had for local OpenClaw, but for Hermes on AKS. OpenClaw on AKS still uses the port-forward + WebUI URL path because the exec-ban VAP blocks exec into the openclaw container. ## What changed cli/src/commands/operator/dialogs/connect.ts splits the Enter handler by (location × runtime kind): - AKS + OpenClaw → existing port-forward path (VAP-bound) - AKS + Hermes → PTY exec into 'agent' container (NEW) - local Docker + OpenClaw → 'openclaw tui' PTY - local Docker + Hermes → 'hermes chat --accept-hooks' PTY (NEW) The two PTY paths share a common _spawnPtyConnect() helper extracted from the old inline body; the OpenClaw port-forward path is now _aksOpenClawConnect(). Both are pure refactors — the byte-identical PTY plumbing (blessed save/restore, raw-mode stdin, Ctrl-\ detach) moved into the helper, no functional change for OpenClaw. ## Why this works for Hermes but not OpenClaw on AKS deploy/helm/kars/templates/admission-pod-exec-ban.yaml has matchConditions: expression: object.container == '' || object.container == 'openclaw' The VAP fires ONLY when the target container is literally named 'openclaw' (or unspecified — which defaults to the first container, which is 'openclaw' in OpenClaw pods). Hermes' container is named 'agent' (controller/src/reconciler/mod.rs:1801 picks the name from the is_openclaw branch), so 'kubectl exec -c agent ...' bypasses the VAP cleanly. This was a deliberate VAP design: the policy targets the literal openclaw runtime container, not 'any agent container'. Hermes (and future runtimes whose container is named 'agent') benefit by design. ## HOME / HERMES_HOME env vars Set explicitly on the exec because kubectl exec does NOT inherit container ENV. Without them, Hermes' ensure_hermes_home() falls back to $HOME/.hermes; since HOME defaults to '/' in kubectl exec sessions, Hermes tries mkdir '/.hermes' on the read-only rootfs and ENOENTs. /sandbox is the writable emptyDir the entrypoint daemon uses for the long-running hermes gateway. ## Tests - 789 CLI vitest tests pass (no new tests — interactive PTY path is exercised by live operator runs, not unit tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
…-runtime interop)
Closes the last gap blocking Hermes ↔ OpenClaw mesh communication.
Until this change, the Python kars-agt-mesh library and the TypeScript
@microsoft/agent-governance-sdk produced INCOMPATIBLE relay frames —
Python-Python and TS-TS interop worked fine, but a Python sender
talking to a TS receiver (or vice versa) silently dropped messages.
## Wire-format divergences fixed
### 1. message frame: structured header, std base64
**Before (Python only):**
{
'v': 1, 'type': 'message',
'ciphertext': '<urlsafe-base64 of (struct.pack(>I, header_len) + header + ct)>'
}
**After (matches TS mesh-client.js::send):**
{
'v': 1, 'type': 'message', 'from': ..., 'to': ..., 'id': ..., 'ts': ...,
'header': {
'dh': '<std-base64 dhPublicKey>',
'pn': <previous_chain_length>,
'n': <message_number>
},
'ciphertext': '<std-base64 ciphertext>'
}
The TS receiver reads frame.header.dh / frame.ciphertext as separate
fields; the old Python shape had no .header, so TS-side .base64ToUint8
got an unexpected packed blob and decrypt errored out (silently
dropped at the SDK boundary).
### 2. establishment: short TS-style keys
**Before:** {initiator_identity_key: ..., ephemeral_public_key: ..., used_one_time_key_id: ...}
**After:** {ik: ..., ek: ..., otk: ...} (matches mesh-client.js::serializeEstablishment)
### 3. KNOCK + first message: TWO frames, not one fused
**Before:** Python fused KNOCK + first ciphertext into a single
'type=knock' frame for one-RTT latency. TS receivers do NOT consume
a 'ciphertext' field on a KNOCK — they only read 'establishment',
call acceptSession, then await a separate 'type=message' frame.
→ first ciphertext was lost on Python-to-TS sends.
**After:** Python sends two distinct frames: 'type=knock' (no ciphertext,
just establishment) followed immediately by 'type=message'. Matches
TS mesh-client.js::establishSession + send.
### 4. std-base64 (not urlsafe) on the wire
JS's btoa / Node's Buffer.toString('base64') produce std-base64 with
'+' and '/'. Python's base64.urlsafe_b64encode produces '-' and '_'.
A TS receiver's atob fails on '-'/'_'; a Python receiver's
base64.b64decode fails on '+'/'_' depending on input. Now all on-the-
wire byte strings use std-base64.
## Backwards compat
Receiver tolerates both shapes for one release cycle:
- _message_frame_to_encrypted accepts BOTH the TS shape and the legacy
packed-ciphertext shape (fallback path)
- _wire_to_establishment accepts BOTH {ik,ek,otk} and the legacy
{initiator_identity_key, ephemeral_public_key, used_one_time_key_id}
- _b64std_decode tolerates urlsafe alphabet on input
A fleet mid-upgrade between old/new pods won't drop in-flight messages.
## Live verification
Sent {b'WIRE_TEST_DIRECT', 16 bytes} parent → analyst via direct
asyncio script with PYTHONPATH pointing at hot-patched client.py.
Parent stderr:
> TEXT '{"v": 1, "type": "knock", "from": "did:mesh:a61...", "establishment": {"ik":..., "ek":..., "otk": 20}}'
> TEXT '{"v": 1, "type": "message", ..., "header": {"dh":..., "pn":0, "n":0}, "ciphertext": "..."}'
Analyst auto_responder.log:
Accepted KNOCK from did:mesh:a61c9cbf...
AUTO_GOT from=did:mesh:a61c9cbf... bytes=16
AUTO_REPLIED bytes=16 to=did:mesh:a61c9cbf...
The 16-byte payload decrypted correctly with the TS-compatible shape.
## Tests
- 8 new wire-format unit tests pin every field-shape contract
- 9 existing kars-agt-mesh unit tests still pass
## Cross-runtime promise
With this commit, a Hermes agent CAN mesh-send to an OpenClaw agent
and vice versa (same relay, same registry, same crypto, now same
wire envelope). End-to-end interop verification on a mixed-runtime
cluster ships as a follow-up — the wire alignment is the prerequisite.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Pal Allakatos <pallakatos@microsoft.com>
User OOTB story:
1. Ran `kars dev` from an earlier (pre-Hermes) branch — chart
installed an older KarsSandbox CRD into kind.
2. Pulled the hermes branch + `cd cli && npm run build` to update
the CLI binary.
3. Did NOT re-run `kars dev` (no apparent need — cluster was up).
4. `kars operator` → `n` → Hermes → Launch
5. `kars add` bundle had `spec.runtime.hermes`; cluster CRD didn't
know that field; kubectl apply rejected with:
ValidationError(KarsSandbox.spec.runtime):
unknown field "hermes" in com.azure.kars.v1alpha1...
The error message was technically accurate but the FIX was buried
behind "what does this even mean" digging. Operator activity log
showed the rejection text but didn't tell the user it's a one-shot
CRD refresh away.
Fix: cli/src/commands/add.ts catch block now detects the three
patterns kubectl emits on stale-CRD rejection ("unknown field",
"Unsupported value", "ValidationError") and prints the exact
chart-template + server-side-apply incantation:
This looks like a CRD schema mismatch — the cluster's KarsSandbox
CRD is older than your local CLI/sources. Refresh the chart:
helm template kars deploy/helm/kars --namespace kars-system \
--include-crds | kubectl apply -f - --server-side --force-conflicts
Or just re-run `kars dev --target local-k8s` — its chart-install
step always refreshes CRDs to the source-of-truth schema.
Doesn't change the behavior of the CRD path itself (kars dev's
helmInstall already does the right thing — see
cli/src/commands/dev/local-k8s.ts:778); this only makes the
diagnostic explicit when an operator-driven kars-add hits the
known stale-CRD failure mode.
Verified:
npm run build + typecheck ⇒ clean
vitest run ⇒ 798 passed
User OOTB on local-k8s after spawning a Hermes sandbox from operator: Failed to pull image "karsacr.azurecr.io/kars-runtime-hermes:latest": dial tcp: lookup karsacr.azurecr.io on 192.168.65.254:53: no such host kind nodes don't have ACR pull creds (and shouldn't — the user isn't on an Azure cluster). Last commit (ea59a9f) added auto-LOAD of the image into kind via loadImageIfPresent, but loading requires the image to exist on the host first — and the host doesn't have it unless the user knows the docker-build command. Fix: extend rebuildDevImages in cli/src/commands/dev/local-k8s.ts with a new "runtime-hermes" build spec. It builds karsacr.azurecr.io/kars-runtime-hermes:latest from sandbox-images/hermes/Dockerfile against the repo root context. The target tag matches DEFAULT_HERMES_IMAGE in the controller, so: 1. The auto-load step in runLocalK8s now finds the image and `kind load`s it into the cluster. 2. When the operator spawns a Hermes sandbox, the controller's image string resolves to the already-loaded image → ImagePullPolicy IfNotPresent + image-present → no pull attempt, no DNS-to-ACR-from-kind failure. Cost: • First build: 3-5 min (Python pip install + Hermes pip install + ripgrep/op binaries). The Dockerfile COPYs runtimes/wheels/ which `ensureAgtWheels` already populated at the top of runLocalK8s, so no wheel-build blocking. • Subsequent runs: docker layer cache hit, < 10 sec. Honors the same `forceAll` flag as the other dev specs. Skips silently if sandbox-images/hermes/Dockerfile is missing (e.g. older checkout) so it can't break the openclaw-only path. Hermes is the only non-OpenClaw runtime auto-built; other runtimes (Anthropic, LangGraph, MAF, Pydantic AI, OpenAI Agents) stay opt-in to keep `kars dev` startup cost bounded. Hermes is the productized runtime in this PR — auto-building it is what makes operator-`n` → Hermes → Launch JUST WORK out of the box. Verified: npm run build + typecheck ⇒ clean vitest run ⇒ 798 passed
…m template` Found while debugging user's persistent ErrImagePull after the manual CRD refresh worked: helm template kars deploy/helm/kars --include-crds | kubectl apply ... WITHOUT `-f deploy/helm/kars/values-local-dev.yaml` re-renders the controller Deployment from default values.yaml — which doesn't include `KARS_DEV_PROFILE=true`. The controller then defaults imagePullPolicy to Always for `:latest` images, so every sandbox pod tries to pull from ACR even when the image is loaded in kind. DNS resolution to karsacr.azurecr.io fails → ErrImagePull forever. This is exactly the failure mode I just sent the user from this PR's previous commit (93938f5) — the kars-add error hint's PRIMARY fix was a naked `helm template`, which is what triggered the side-effect. Reorder the hint: 1. Primary: `kars dev --target local-k8s` (correct overlay, no risk of overwriting controller env). 2. Fallback (only if you must apply CRD by hand): include `-f deploy/helm/kars/values-local-dev.yaml` so the controller keeps its dev semantics. No code path changed — just the user-facing diagnostic string.
User stuck on persistent ErrImagePull for kars-runtime-hermes even after the image was loaded into kind (`docker exec kars-dev-control- plane crictl images | grep hermes` confirmed). The kubelet was still attempting a network pull because the controller emitted `imagePullPolicy: Always` for the `:latest` tag. Root cause: `KARS_DEV_PROFILE=true` was set ONLY by `kars dev`'s dynamic per-run overlay (cli/src/commands/dev/local-k8s.ts:944), NOT by the static `values-local-dev.yaml` overlay. Result: any out-of-band chart apply that just used `-f values-local-dev.yaml` (e.g. the CRD-refresh workflow we recommend when source CRDs drift forward) silently dropped the env var → controller's pull-policy helper at controller/src/reconciler/mod.rs:1291 fell into the `Always` branch for `:latest` images → every sandbox pod tried to pull from ACR even when the image was kind-loaded → ErrImagePull forever on machines without ACR network reachability. Fix: pin `KARS_DEV_PROFILE: "true"` in the static overlay alongside `LEADER_ELECTION_ENABLED: "false"`. Now anyone applying `values-local-dev.yaml` gets full dev semantics (IfNotPresent pull policy, dev-mode relaxations, etc.) without depending on the CLI's dynamic overlay being layered on top. The CLI's dynamic overlay still re-emits the same key idempotently (see cli/src/commands/dev/local-k8s.ts ::provisionDevCreds line 944); helm/kubectl deduplicate by env-var `name`, so no double emission downside. After this commit, the user can run any of: • `kars dev --target local-k8s` (always worked) • `helm template kars deploy/helm/kars -f deploy/helm/kars/values-local-dev.yaml --include-crds | kubectl apply -f - --server-side --force-conflicts` • `helm upgrade kars deploy/helm/kars -f deploy/helm/kars/values-local-dev.yaml` And all three yield a working dev controller.
… apply` Following on from 99deca3 / 2c0c912 and a user-driven debug session that uncovered why the "helm template -f values-local-dev.yaml | kubectl apply" workaround keeps half-bricking local-k8s clusters: The static `values-local-dev.yaml` overlay does NOT contain the inference creds (AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, KARS_PROVIDER, COPILOT_GITHUB_TOKEN). Those are emitted by the per-run DYNAMIC overlay built in `cli/src/commands/dev/local-k8s.ts::provisionDevCreds`. So any external `helm template ... -f values-local-dev.yaml | kubectl apply` overwrites the controller Deployment's pod-spec env with ONLY the static-overlay values — silently nuking the dynamic creds. Result: every subsequent reconcile fails with: ERROR "No inference endpoint configured" and Deployments never get written. Pods never get created. The user then sees the operator's spawn dialog "succeed" (kars add times out on the wait loop, exits 0 from the previous-spinner branch — though now exits 1 from f39d425 — but in either case the CR is reconciled into nothing because the controller can't proceed). Fix: simplify the CRD-mismatch hint to recommend ONLY `kars dev --target local-k8s` (the source of truth for local dev). Drop the helm-template fallback that was actively harmful. Add an explicit DO NOT warning so the next person reading this hint doesn't try the obvious-but-wrong shortcut. Verified: npm run build ⇒ clean vitest run ⇒ 798 passed
This session shipped 12 OOTB blockers — every one diagnosable from
cluster state + controller logs + chart source. Captures the design
for an in-cluster SRE agent that auto-walks the same diagnostic
ladder. Filed as docs/blueprints/07-kars-sre-proposal.md so it gets
discoverable PR-review on its own merits without bloating this
Hermes-runtime PR.
Phased implementation:
1. MVP (kars-sre-mvp todo): 5 read-only tools. ~500 LOC, ~1 day.
2. Phase 2 (blocked on Phase 1): sre_apply_fix + AGT approval
flow.
3. Phase 3 (blocked on Phase 2): continuous-watcher mode.
Validation gate: the merged agent must autonomously diagnose +
propose fixes for every one of the 12 OOTB blockers from THIS
session, given only the cluster state that existed at the moment
each was hit. That's a built-in regression corpus.
No code shipped in this commit — design only. Implementation
lands in a separate PR series.
…s-control section Two pieces: 1. fix(cli): rebuildDevImages always rebuilds controller + router User session hit a 30-minute debug loop because their kind cluster was running a `kars-controller:dev` image built BEFORE commit 493c118 (2026-06-04), which added the `dev_profile` check at controller/src/reconciler/mod.rs:1291: let pull_policy = if ctx.dev_profile || !image.ends_with(":latest") { "IfNotPresent" } else { "Always" }; Without dev_profile in the running controller, every `:latest` image got `imagePullPolicy: Always` — including the local kind-loaded kars-runtime-hermes — so the kubelet ALWAYS attempted a manifest fetch from karsacr.azurecr.io which kind cannot reach → ErrImagePull forever. Why this was hard to spot: rebuildDevImages skipped already-present images entirely. Running `kars dev --target local-k8s` after pulling new controller source did NOT rebuild the controller image. The user's check for KARS_DEV_PROFILE=true on the controller pod passed (env was correctly injected by the helm overlay), but the running controller process couldn't USE the env because the binary predates the env-reading code. Fix: introduce ALWAYS_REBUILD = {"controller", "inference-router"}. Both bottom out at a pre-staged Rust binary COPY (see staging in stage-rust-bin.ts), so docker layer cache makes the rebuild ~5-30s. That cost is well worth not silently running stale controllers. Sandbox + runtime images stay opt-in because their builds are minutes long. 2. docs(sre): expand kars-sre access-control design Added §6.1-6.6 to docs/blueprints/07-kars-sre-proposal.md: - 6.1 Tier 1 (MVP target): in-cluster ServiceAccount token on local-k8s — works on kind without any Entra/AKS dependency - 6.2 Tier 2 (Phase 2): AKS Workload Identity federation; byte- identical agent code, purely additive operator glue - 6.3 Complete ClusterRole `kars-sre-reader` spec — single authorization gate, every absent permission is deliberate - 6.4 Secrets handling — router-side .data stripping (~30 LOC in inference-router/src/proxy.rs) + RBAC defense in depth - 6.5 Phase-2 write actions via short-lived (5-min TTL), per-action ServiceAccount tokens minted on operator approval; standing blast radius stays read-only - 6.6 Egress already covered Verified: npm run build + typecheck ⇒ clean vitest run ⇒ 798 passed
…copy User on local-k8s after the image-loading saga finally got past ImagePullBackOff — pod proceeded to crash on agent-container startup with: cp: preserving permissions for '/sandbox/.hermes/plugins/kars/__init__.py': Operation not permitted cp: preserving permissions for '/sandbox/.hermes/plugins/kars/discover.py': Operation not permitted …(13 such lines) Root cause: sandbox-images/hermes/entrypoint.sh line 73 used `cp -a`, which preserves owner + mode + atime metadata. The staged source at /opt/kars-hermes-stage/plugins/kars/ was chowned root:root at image build time. The entrypoint runs as UID 1000 (sandbox user) on a `readOnlyRootFilesystem: true` pod, so preserving root ownership → EPERM from the kernel → `set -e` at the top of the script kills the container with the "Operation not permitted" spam as the entire visible output. Cryptic-as-hell symptom; trivial fix. Fix: `cp -r` instead of `cp -a`. Files end up owned by the copying user (UID 1000), which is what we want anyway because that's the UID hermes runs as. The source files have 0444 from the `chmod -R a+rX` in sandbox-images/hermes/Dockerfile, so they remain readable post-copy; no follow-up chmod needed. Prior art: the openclaw entrypoint at sandbox-images/openclaw/ entrypoint.sh:1273 uses `cp --no-preserve=mode` for the same reason (it copies the package.json staged at image build time into a runtime location). We're now consistent. No new tests — the failure mode is verifiable only against a real sandbox pod, and the OOTB-fresh-machine-gate todo will cover it once that CI lane lands.
… gpt-5.4)
User picked `claude-opus-4.7` in the operator spawn dialog; the
resulting Hermes pod was using gpt-5.4 instead.
Root cause: sandbox-images/hermes/entrypoint.sh line 171 wrote
echo " default: \"${AZURE_OPENAI_DEPLOYMENT:-gpt-5.4}\""
into the auto-generated config.yaml. AZURE_OPENAI_DEPLOYMENT is
NOT injected into the agent container by the controller — only
into the inference-router container's env (see
controller/src/reconciler/mod.rs:1656 → router_env, vs the
openclaw_env path at line 1335 which only sets the generic
KARS_MODEL). So the hermes entrypoint fell through to the
hardcoded gpt-5.4 default in EVERY case.
The generic kars-runtime-contract env var is KARS_MODEL (controller/
src/reconciler/mod.rs:1335 — "Generic alias readable by any
runtime — Hermes / OpenAIAgents / MAF / BYO all read KARS_MODEL").
That's the env Hermes should be honouring.
Fix: prefer KARS_MODEL first, fall back to AZURE_OPENAI_DEPLOYMENT
(for hand-crafted dev overlays that still set the legacy name),
keep gpt-5.4 as last-resort default to keep the boot banner
sensible when neither is set.
${KARS_MODEL:-${AZURE_OPENAI_DEPLOYMENT:-gpt-5.4}}
User-visible effect: `kars operator → n → Hermes → model:
claude-opus-4.7 → Launch` now actually runs claude-opus-4.7 in the
pod, not gpt-5.4. Same for every other Copilot/Foundry model the
user can pick.
…spawns)
User session: a Hermes parent agent tried to spawn a sub-agent with
the Hermes plugin's documented `role` arg. The router rejected with
HTTP 422 Unprocessable Entity because:
inference-router/src/spawn/mod.rs::SpawnRequest had #[serde(
deny_unknown_fields)] and no `role` field. The Hermes plugin
wrapper (runtimes/hermes/src/kars_runtime_hermes/plugin/spawn.py)
sent `role` as a top-level body field and got 422.
The Hermes plugin's docstring + the kars_spawn schema both expose
`role` as a key arg: "Short persona/role description that siblings
can find by role." It was always a missing-field in the router's
request type, not bad client code.
Fix:
1. Add `pub role: Option<String>` to SpawnRequest in
inference-router/src/spawn/mod.rs:48 (the schema-deny-strict
deserialize site).
2. Wire it through every existing SpawnRequest construction site
(handoff/mod.rs × 6, spawn/dev_profile_test.rs × 1, spawn/mod.rs
× 2 — the docker.rs snapshot path and the list-children
reconstruction path).
3. In build_sub_agent_crd_with_labels, when role is non-empty,
emit it as `kars.azure.com/role` label on the child CRD so:
(a) sibling discovery via `kubectl get karssandbox
-l kars.azure.com/role=auditor` works,
(b) the parent's local roster can recover role on restart by
reading children with kars.azure.com/parent + reading
kars.azure.com/role,
(c) the handoff/restore path snapshot can preserve role
across re-spawn (the snapshot-reconstruction site now reads
the label back).
4. Sanitize the label: K8s requires ≤63 chars + a specific
character class. Replace disallowed chars with `-`, truncate.
Don't fail the spawn over a space in the LLM's free-form
persona text.
User-visible effect: `Hermes parent → kars_spawn(name: zsolti, role:
"data analyst")` now succeeds AND records the role for downstream
discovery. Previously it failed with 422 and the Hermes plugin's
wrapper retried without role (silently dropping the field's whole
purpose).
The agent that diagnosed this was, charmingly, correct.
Verified:
cargo build/clippy/fmt ⇒ clean
cargo test --lib spawn ⇒ 16 passed
ci/check-loc.sh ⇒ clean
The honest answer to "is OOTB really working?" is "you'd have to
actually run the full flow on a fresh machine to know". This
session shipped 16 OOTB blockers that no unit test caught — every
single one a wire-format failure across module boundaries that
only surfaces against a real cluster.
This script makes that question answerable by anyone in one command:
export KARS_OOTB_COPILOT_TOKEN="gho_..."
bash scripts/smoke/fresh-machine-ootb.sh
What it does:
1. Wipes carried state (kind cluster, AGT clone, ~/.kars, npm-link)
2. Fresh git clone into /tmp/kars-ootb-smoke
3. cd cli && npm ci && npm run build && npm link
4. kars dev --target local-k8s (non-interactive via seeded creds)
5. kars add one OpenClaw + one Hermes sandbox
6. Polls until both pods Running 2/2 (5-min timeout each)
7. Tears down (or --keep)
Exits 0 iff every step succeeds. On failure prints the precise
command that failed AND the pod's diagnostic output, so the
regression is reproducible from the script output alone.
Catches everything from the 2026-06-08 Hermes session:
• AGT auto-clone missing → kars dev fails at chart-apply
• Stale CRD → kars add 4xx
• Stale controller / runtime images → ImagePullBackOff
• KARS_DEV_PROFILE drift → same
• `cp -a` perm error → CrashLoopBackOff
• SpawnRequest missing role → kars add exit 1
• etc.
Limitations today:
• Needs a real Copilot OAuth token in env (can't run on a public
GHA runner without secret wiring). The ootb-fresh-machine-gate
todo tracks moving this to CI.
• Single-cluster only; AKS / federation covered by separate
interop scripts.
• Doesn't validate model routing post-spawn yet (next iteration).
README.md documents usage, what it catches, when to run, and
how to extend for new wired runtimes.
Not wired as a PR gate yet — runs manually. Filed
`ootb-fresh-machine-gate` for the CI lane.
…tch deck
Practitioner-grade visual language (mix of Patrick Collison / Stripe Press,
Bret Victor, and Stripe-docs style — selected per slide intent):
• Title + close : dark sandwich, 168pt mark, single tagline
• Pillar overview : eyebrow + heading + lede paragraph + 4 named primitives
• Sandbox : Victor-style — one named artefact, real CRD field
labels (UID 1000, readOnlyRootFilesystem, etc.)
• Sandbox · the gate : Stripe-docs — the 6 actual iptables rules in a
code block + prose explanation on the right
• Mesh : the real KNOCK frame JSON as the visual artefact
(with v/type/from/to/id/ts/intent/establishment fields)
• Governance · policy : the real InferencePolicy CR snippet as the artefact
• Governance · stack : 4 layers vertically with name + body + source ref
• Blueprints : 6 named shapes, real meta per shape (Kata + SEV-SNP,
A2A bridge, private model + signed allowlist, etc.)
• Multi-runtime : 8 wired runtimes named, with what each is in one line
• Built on AGT : the 4 actual PRs/contributions named
• What's next : the 4 actual shipping targets named
• Try it : the actual `kars dev` command sequence as code block
Every claim in the deck is ground-truthed against repo HEAD via
deep-dive explorer runs (see docs/showcase/outline.md for full
citations). Source file: /tmp/build-deck.js → pptxgenjs render.
Diagram pair (Excalidraw): 6 .excalidraw files using single accent
teal (#028090), Helvetica throughout, no inline arrows where layout
implies the relationship. Kept minimal because the deck slides carry
the detail; diagrams are hero shots, not info-dense.
Source-of-truth doc: docs/showcase/outline.md — every claim with
file:line citations against ground-truthed repo source. Updating
outline.md → re-running /tmp/build-deck.js regenerates the deck.
… tiles cropped) Visual QA caught two layout bugs in the first render: - Slide 7 (MESH/KNOCK frame): JSON code block was 17 lines @ fontSize 12 in a 2.9" panel — closing braces overflowed below the gray panel. Fixed: shrunk to fontSize 11, panel taller (3.55"), raised to y:3.85 to use the available space without colliding with the lede. - Slide 11 (RUNTIMES): 4 tiles @ 3.85" wide + 0.15" gaps = 15.85" total, but the slide is only 13.3" wide — x0 went negative, cropping leftmost tiles. Fixed: tw=2.9" → 12.25" total, centred with 0.525" left/right margin. Re-rendered all 15 slides, visual QA pass clean across the board. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Vendoring /tmp/build-deck.js into scripts/showcase/ so the deck is reproducible from a clean clone — no more 'lost the script that made the slides' problem. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| } | ||
|
|
||
| // section divider (very minimal — used between major narrative arcs) | ||
| function section(s, n, txt) { |
…-shaped
Major restructure per Pal's feedback (less competitive, more architecture):
REMOVED: 'THE RACE' slide (competitor names)
ADDED: 'THE OUTCOME' slide — 4 measurable outcomes:
every call audited · sandbox in minutes · one policy plane · default-deny egress
ADDED (architecture deep-dive, §3):
§3 THE CORE — three boxes: Controller / 11 CRDs / Inference Router
§3.1 ROUTER REQUEST FLOW — 6 stages: agent → iptables → router → policy → audit → upstream
§3.2 ROUTER INTERNALS — 8 routes + 8 subsystems in two columns
§3.3 CONTROLLER LOOP — KarsSandbox → 9 named Kubernetes primitives
§3.4 STATUS & OBSERVABILITY — phase taxonomy + conditions YAML
§3.5 CRD CATALOG — all 11 CRDs tabled with scope + reconciler
ADDED (policy in practice, §4):
§4 InferencePolicy (existing slide reworked)
§4.1 ToolPolicy + EgressApproval (real CR snippets side by side)
REFRAMED:
§10 WHAT'S NEXT — outcome-shaped: 'capability → what becomes possible → proof'
Visual QA: 4 layout bugs caught on first render (slides 9/11/13/20)
and fixed; final QA pass clean on all 21 slides.
Reproduces via: NODE_PATH=$(npm root -g) node scripts/showcase/build-deck.js
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| } | ||
|
|
||
| // right-column prose paired with codeBlock | ||
| function rightProse(s, paragraphs, opts = {}) { |
…gress slide
Two new slides per Pal's feedback:
NEW SLIDE 4 — HIGH-LEVEL ARCHITECTURE diagram (right after the 'WHAT KARS IS' dark
statement, before drilling into 'THE CORE'). A single picture that shows the whole
shape: dashed cluster boundary containing three boxes (kars CRDs · kars-controller ·
Sandbox pod), with the sandbox pod showing both inner containers (agent UID 1000 +
inference-router UID 1001). Below the cluster: a row of external services chips
(Azure OpenAI · Anthropic · OpenAI · Bedrock · MCP · A2A peers · AGT relay) with
'only path out' label connecting the architecture to the providers.
NEW SLIDE 15 — NETWORK EGRESS · learn it, sign it, enforce it. The full story:
- Top half: two side-by-side panels showing Learn mode (default — record every host
into the next allowlist proposal) vs Strict mode (production — anything outside the
signed allowlist gets 4xx; EgressApproval grants layer on top; fails closed)
- Bottom half: 5-stage signed-OCI-allowlist pipeline:
kars egress --sign → OCI artifact (ACR/ghcr) → cosign verify (Fulcio + SAN)
→ ConfigMap + digest → router · L7 hot-reload
- Source refs: controller/src/policy_fetcher.rs · egress_allowlist_compile.rs ·
inference-router/src/egress_allowlist_loader.rs
Section eyebrows renumbered to keep §3 sub-numbering consistent after insertion.
Final QA: clean on all 23 slides.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fixes copyright-headers ci-gate failure on PR #396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Act II)
Three new slides between TRY IT and CLOSE that frame the live demo
narrative — per the dual-act structure: first act showcases the secure
runtime with a normal agent, intermission resets the audience between
acts, second act shows the SRE-knowledge agent fixing the cluster on
the same kars install.
Slide 23 — ACT I · 'Secure runtime, in motion'
Five-step timeline: kars dev → kubectl get karssandbox →
kars add research-agent → kars connect → web.fetch is ToolPolicy-
gated → Telegram approval → answer + audit row.
Slide 24 — INTERMISSION · 'Same cluster. Second agent.' (dark)
Resets the audience between the runtime showcase and the SRE
extension. Same isolation, same policy plane, same audit log —
just different tools. Foreshadows the approval gate.
Slide 25 — ACT II · 'The cluster fixes itself. With your nod.'
Five-step timeline: kars install sre → controlled break of
NetworkPolicy kars-mesh-egress (TCP/8083 to relay) → kars-sre
Telegram ping with diagnosis → kars connect sre → health overview
+ proposed NP patch + awaiting approval → operator approves via
Telegram → controller reconciles → research-agent back Running 2/2.
QA: rendered + visually verified all 4 new+shifted slides (23-26).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Switched Act II scenario from a kars-internal NetworkPolicy issue to a pure Kubernetes mistake — image tag typo causing ImagePullBackOff on a user workload (deployment 'webshop' in ns 'webshop'). Why: the previous scenario tied the SRE story to kars internals, which obscures the message. The point is that kars-sre can fix ANY workload on the cluster — not just kars sandboxes. New step list: 1. kars install sre (read-only kubectl tools + 2 approval mutators) 2. kubectl apply -f webshop.yaml (image nginx:1.27-typo) 3. Telegram diagnosis from kars-sre with closest in-use tag 4. kars connect sre — health overview + 'kubectl set image' proposal 5. Approve via Telegram — rollout completes — Running 3/3 — 🟢 This is the canonical K8s demo: audience instantly recognises ImagePullBackOff, the fix is a discrete one-liner, no load-gen needed, and the agent's value (find the right replacement tag) is immediately visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pal Lakatos-Toth (pallakatos)
enabled auto-merge (squash)
June 9, 2026 08:17
| } | ||
|
|
||
| // right-column prose paired with codeBlock | ||
| function rightProse(s, paragraphs, opts = {}) { |
| } | ||
|
|
||
| // section divider (very minimal — used between major narrative arcs) | ||
| function section(s, n, txt) { |
…ge containment
Expands the kars-sre proposal from a Phase-1/2/3 sketch into a
production-grade design with all the pieces a real first-class kars
subsystem needs.
NEW sections (§7):
7.1 Slice catalog — 7 slices (S0-S6) with explicit dependencies
and demoability per slice; S0 is a demo harness so the Act II
flow can be walked by hand before any SRE code exists
7.2 K8s diagnostic toolset — workload owner graph,
sre_image_probe, sre_endpoints_inspect, sre_what_changed
(informer-cache backed, NOT events-only), sre_top with
graceful degrade, exact RBAC manifest including
events.k8s.io, discovery.k8s.io, metrics.k8s.io
7.3 Notification via governed 'kars_notify_human' tool — NOT
Hermes' deregistered send_message (closes critique gap)
7.4 Productisation gaps closed (table)
7.5 Open decisions (Q1-Q6) with explicit resolutions
7.6 Mapping to existing kars subsystems (don't parallel, reuse)
7.7 Threat model — typed actions (not shell commands),
protected-resource denylist (RBAC/secrets/CRDs/webhooks/kars
governance state), three-layer enforcement (plugin compiler +
controller pre-flight + admission backstop), prompt-injection
containment via quote-and-fence + action-must-target-described-
resource
7.8 Privilege containment — 9 boundaries ensuring kars-sre
privileges cannot be inherited or used by any other agent:
7.8.1 Plugin packaging — sre_* tools only ship in
dedicated kars/sre-sandbox image; standard hermes
pods don't have the code
7.8.2 Sandbox uniqueness — VAP enforces at-most-one
KarsSandbox cluster-wide with role=sre label
7.8.3 RBAC subject pinning — VAP requires explicit
(ServiceAccount, kars-sre, sandbox|sre-writer)
subjects, no group/wildcard bindings
7.8.4 Writer-SA token containment — automountSA off,
boundObjectRef to pod, 5-min TTL, in-memory env
only, blocked from /proc by seccomp
7.8.5 Spawn isolation — sre image disables kars_spawn;
controller-side reject sub-sandboxes in kars-sre
7.8.6 Mesh isolation — tool.invoke intent rejected at
two layers; sre_apply_fix marked callerKind: local-only
7.8.7 NetworkPolicy — admin port reachable only from
kars-system controller and monitoring scrapes
7.8.8 Audit — every privilege use observable; uncorrelated
activity is itself a compromise signal
7.8.9 Threat-model conclusion — what a fully-compromised
SRE agent CANNOT do
7.9 Identity & naming — resolves kars-kars-sre gotcha; controller
RBAC delta for serviceaccounts/token + scoped CRB ops
7.10 Observability — 7-outcome counter, 4 latency histograms,
5-row audit correlation chain, recovery semantics
7.11 What's NOT yet specified (8 design artefacts before PR slicing)
Also fills in the previously empty §6.6 Egress with explicit
dev/demo (Learn) vs production (Strict) lifecycle.
The maximal blast radius of a fully-compromised SRE agent is now
formally bounded: read-only inspection + one typed action against
non-governance workload state per operator approval + channel posts.
Nothing else.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… at all
The original §7.8.6 said the SRE agent COULD send inbox messages and
just rejected incoming tool.invoke intents. That left the entire mesh
machinery in the SRE pod (Signal handshake parser, X3DH state,
Double Ratchet, relay WebSocket, registry registration) as attack
surface for zero functional reason.
The SRE agent's only counterparties are:
- the operator (Telegram/Slack channel + 'kars sre' CLI + WebUI)
- the Kubernetes apiserver
It never needs to converse with another agent. So mesh comes OFF
entirely, enforced at three layers:
1. Spec: KarsSandbox.spec.mesh.enabled: false — controller does
not provision mesh credentials, mint a mesh identity, or open
the egress allowance to agentmesh-relay
2. Image: kars/sre-sandbox image does not ship the mesh Python
package or register any kars_mesh_* tools
3. Network: the egress NetworkPolicy does not include the
agentmesh namespace in its allowlist
A KNOCK frame addressed to the SRE agent is dropped at registry
lookup — the SRE agent has no DID and is not registered.
Cascading updates:
- §6.6 Egress: agentmesh-relay + agentmesh-registry removed from
default allowed-endpoints
- §7.6 Mapping: Mesh row changed from 'potentially in S5+' to
'never — not needed'
- §7.4 productisation table: boundary count updated from 8 to 9;
the 'mesh tool.invoke rejected' phrasing replaced with the
stronger 'mesh disabled at the source (no relay socket, no DID,
not in registry)'
- §7.8.9 'cannot be commanded by remote agent over mesh' updated
to clarify it's because the SRE agent isn't on the mesh at all,
not because of message-level rejection
Net effect: one whole subsystem removed from the SRE pod with no
functional loss. Smaller image, smaller attack surface, simpler
deployment story, simpler audit story.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First end-to-end Hermes docker smoke test surfaced two real bugs from the A1 ship:
hermes-agent==0.5.1doesn't exist on PyPI — the 0.5.x assumption was from misreading the Hermes README's Homebrew formula tag (5.1.14). PyPI uses 0.x.y at 0.15.2 latest. Bumped pin to 0.15.2.standaloneplugins as opt-in viaplugins.enabledin config.yaml. The entrypoint was materializing the plugin into the right path ($HERMES_HOME/plugins/kars/) but never addingkarsto the allow-list — so it was discovered and silently skipped (error='not enabled in config').Bonus:
ripgrepnot in Azure Linux 3 tdnf causedtdnf install -yto fail the whole layer. Hermes'file_searchfalls back to grep cleanly, so dropped it. Image now builds in ~30s.Verification
docker build --platform linux/amd64 -f sandbox-images/hermes/Dockerfile -t kars-sandbox-hermes:dev .succeedshermes_cli.plugins.discover_plugins()loadskarsplugin from$HERMES_HOME/plugins/kars/with 10 tools + 2 hooks (pre_tool_call + post_tool_call)plugins.enabled: [kars]andmcp_servers.platformblocksPlugin contract confirmation: Hermes 0.15.2's
PluginContext.register_tool/register_hooksignatures andplugin.yamlschema (provides_tools/provides_hooks) match exactly what the A1 plugin code was already built for.Security
See
docs/internal/security-audits/2026-06-04-hermes-act1-docker-smoke-fixes.md— no new threat surface beyond what existing A1 audits already cover; this commit only flips the previously-audited surface from dormant to live.